Add exclude_dynamic_sections to SystemPromptPreset for cross-user caching#797
Add exclude_dynamic_sections to SystemPromptPreset for cross-user caching#797
Conversation
Parity with the TypeScript SDK option of the same name. When set, the CLI strips per-user dynamic sections (cwd, auto-memory, git status) from the preset system prompt and re-injects them into the first user message, so the system prompt stays static and cacheable across users. Closes #784
There was a problem hiding this comment.
LGTM — the inline comment flags the CHANGELOG reference nit.
Extended reasoning...
Overview
The PR adds a single optional field exclude_dynamic_sections to SystemPromptPreset and threads it through the type system (types.py) → both client paths (_internal/client.py, client.py) → Query.__init__ (_internal/query.py) → the initialize control request as camelCase excludeDynamicSections. Seven files changed, but the diff is small and mechanical.
Security risks
None. The field is a boolean sent only in the initialize control message to the CLI subprocess. No external input is accepted without type-checking (isinstance(eds, bool)), and older CLIs silently ignore unknown initialize fields.
Level of scrutiny
Low. This is a pure SDK-parity feature (matching the TypeScript SDK), with no new permissions, no auth changes, and no behavioral impact on the main code path when omitted. The logic is a straightforward conditional inclusion.
Other factors
Both unit tests added in test_query.py directly verify that the field is sent when set and absent when unset. test_types.py covers the Options round-trip. The only issue found is the CHANGELOG referencing issue #784 instead of PR #797 — already flagged as an inline nit.
E2E Test ResultsMethod: Python SDK from this branch, pointed at a locally built Claude Code CLI containing the corresponding handling for Test script
|
| Check | Result |
|---|---|
| Default mode knows cwd (no regression) | ✅ PASS |
| Exclude mode still knows cwd (re-injection works) | ✅ PASS |
| Option reaches the CLI (system prompt changes) | ✅ run 3 read 63961 (shared prefix), not 73868 (full default) |
| Cross-cwd cache improves | ✅ cache_creation 9915 → 7065 (−29%); run 4 reads run 3's full system-prompt cache |
Summary: exclude_dynamic_sections=True is correctly threaded through the Python SDK's initialize control message; with a CLI that supports it, the system prompt becomes byte-identical across different cwd values (run 4 cache-reads run 3's full prompt), and the model still receives the working-directory info via the first user message. Default behavior unchanged.
There was a problem hiding this comment.
LGTM — my prior CHANGELOG comment was addressed by removing the edit entirely.
Extended reasoning...
Overview
Adds exclude_dynamic_sections: NotRequired[bool] to SystemPromptPreset, threads it through both ClaudeSDKClient.connect() and InternalClient.process_query(), and includes it in the initialize control request under the camelCase wire-format key excludeDynamicSections. Six files touched: the two client paths, Query, types.py, and two test modules.
Security risks
None. This is an opt-in flag forwarded to the CLI via the existing control protocol. The CLI ignores unknown initialize fields on older versions, so there is no injection or bypass risk.
Level of scrutiny
Low. The change is mechanical parity with an existing TypeScript SDK option. It strictly follows the pattern already established for agents / _agents: extract from options → store on Query → conditionally include in initialize request. No auth, crypto, or permission-sensitive paths are touched.
Other factors
Two focused unit tests cover the presence and absence of the field in the initialize payload. The single prior review concern (wrong CHANGELOG entry number) was resolved by removing the manual CHANGELOG edit, which is consistent with the repo's auto-generation workflow. No outstanding reviewer comments remain.
E2E Test Results — with large
|
| Check | Result |
|---|---|
| Default mode knows cwd (no regression) | ✅ PASS |
| Exclude mode knows cwd (re-injection works) | ✅ PASS |
Cross-cwd cache_creation |
24,315 → 7,065 (−71%, 17,250 tokens saved) |
Exclude-mode residual independent of append size |
✅ 7065 — identical to the no-append run |
| Run 4 reads run 3's full system-prompt cache | ✅ cache_read=81218 (vs 63966 in default mode) |
Summary: With exclude_dynamic_sections=True, the entire system prompt including the large append block caches cross-user (run 4 reads 81k from run 3's cache despite a different cwd and git status). Only the per-user first user message (~7k) is recreated. Without the flag, the append block plus everything after the cwd-varying bytes (~24k) is a per-user miss. Savings scale linearly with append size.
Test script (e2e_797_append.py)
"""E2E: exclude_dynamic_sections with ~6k-token append (PR #797)."""
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, query
CLI = "<path-to-unreleased-cli>"
ALICE = "/Users/qing/code/tmp/e2e-alice-git"
BOB = "/Users/qing/code/tmp/e2e-bob-git"
PROMPT = (
"What is my primary working directory? Reply with only the absolute path, "
"nothing else."
)
# ~74k chars ≈ ~6k tokens (highly repetitive text tokenizes efficiently)
APPEND = ("You are a helpful coding assistant. " * 10 + "\n") * 200
async def run(label: str, cwd: str, exclude: bool) -> dict:
sp: dict = {"type": "preset", "preset": "claude_code", "append": APPEND}
if exclude:
sp["exclude_dynamic_sections"] = True
opts = ClaudeAgentOptions(
cwd=cwd,
max_turns=1,
model="claude-haiku-4-5-20251001",
system_prompt=sp,
allowed_tools=[],
cli_path=CLI,
)
answer = ""
usage: dict = {}
err = None
try:
async for msg in query(prompt=PROMPT, options=opts):
name = type(msg).__name__
if name == "AssistantMessage":
for block in getattr(msg, "content", []):
if type(block).__name__ == "TextBlock":
answer += getattr(block, "text", "")
elif name == "ResultMessage":
usage = getattr(msg, "usage", None) or {}
except Exception as e: # noqa: BLE001
err = f"{type(e).__name__}: {e}"
cc = usage.get("cache_creation_input_tokens", "-")
cr = usage.get("cache_read_input_tokens", "-")
line = (
f"{label:16s} | cwd={cwd} | answer={answer.strip()!r} | "
f"cache_creation={cc} cache_read={cr}"
)
if err:
line += f" | ERROR={err}"
print(line)
return {"answer": answer.strip(), "cc": cc, "cr": cr, "err": err}
async def main() -> None:
print("=== PR 797 e2e: exclude_dynamic_sections with ~6k-token append ===")
print(f"CLI: {CLI}")
print(f"append size: {len(APPEND)} chars\n")
r1 = await run("1-default-alice", ALICE, exclude=False)
r2 = await run("2-default-bob", BOB, exclude=False)
r3 = await run("3-exclude-alice", ALICE, exclude=True)
r4 = await run("4-exclude-bob", BOB, exclude=True)
print("\n=== Analysis ===")
ok1 = "e2e-alice-git" in r1["answer"]
ok34 = "e2e-alice-git" in r3["answer"] and "e2e-bob-git" in r4["answer"]
print(f"default mode knows cwd (no regression): {'PASS' if ok1 else 'FAIL'}")
print(f"exclude mode still knows cwd (re-inject): {'PASS' if ok34 else 'FAIL'}")
d = r2["cc"] if isinstance(r2["cc"], int) else 0
e = r4["cc"] if isinstance(r4["cc"], int) else 0
if d and e:
pct = round(100 * (d - e) / d)
print(f"cross-cwd cache_creation: {d} -> {e} (-{pct}%, {d - e} tokens saved)")
else:
print(f"cross-cwd cache_creation: {d} -> {e}")
if __name__ == "__main__":
asyncio.run(main())
Summary
Adds
exclude_dynamic_sectionstoSystemPromptPreset, bringing the Python SDK to parity with the TypeScript SDK option of the same name.When set, the Claude Code CLI strips per-user dynamic sections (working directory, auto-memory, git status) from the preset system prompt and re-injects them into the first user message instead. This makes the system prompt byte-identical across users with different
cwdvalues, so the prompt-caching prefix can hit cross-user — useful for multi-user fleets that share the same preset +appendconfiguration.Usage
Tradeoffs
system_promptis a plain string.Compatibility
The option is sent via the SDK's
initializecontrol message. Older Claude Code CLI versions silently ignore unknown initialize fields, so this is safe to set unconditionally — it becomes effective once the bundled CLI supports it.Closes #784